import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

class Reader{
  Reader(this.data, this.length);
  final Stream<List<int>> data;
  final int length;
  Map<String, dynamic> meta={};
  String? ext;
}
class Cached{
  Cached(this.data, this.ext, this.meta);

  final Uint8List data;
  final String? ext;
  final Map<String, dynamic> meta;
}

typedef ReaderBuilder = Future<Reader> Function();

class _CacheInfo{
  String ext="tmp";
  DateTime? expireAt;
  String? etag;
  File? cacheFile;
  Map<String, dynamic> meta={};

  _CacheInfo();
  _CacheInfo.fromJsonData(Uint8List data){
    Map<String, dynamic> json = jsonDecode(utf8.decode(data));

    ext = json["ext"];
    int? expireMill = json["expireAt"];
    if(expireMill != null){
      expireAt = DateTime.fromMillisecondsSinceEpoch(expireMill);
    }
    etag = json['etag'];
    for(final key in json.keys){
      if(['ext', 'expireAt', 'etag'].contains(key)){
        continue;
      }
      meta[key]=json[key];
    }
  }


  List<int> toJson(){
    final m = {
      'ext': ext,
      'expireAt': expireAt?.millisecondsSinceEpoch,
      'etag': etag
    };
    for(final key in meta.keys){
      m[key]=meta[key];
    }

    final jStr = jsonEncode(m);
    return utf8.encode(jStr);
  }
}

/// [cumulativeBytesLoaded] is The number of bytes that have been received across the wire thus far.
/// [expectedTotalBytes] The expected number of bytes that need to be received to finish loading the data.
typedef CallbackOnProcess =void Function(int cumulativeBytesLoaded, int expectedTotalBytes);
/// CacheManager.
class CacheManager {
  String _cacheName(String id, String ext){
    return "tmp_${id}_data.$ext";
  }
  String _infoName(String id){
    return "tmp_${id}_info.json";
  }
  Future<String> get tempDirPath async{
    final tempDir = await getTemporaryDirectory();
    final path = p.join(tempDir.path, 'f_cache');
    final dir = Directory(path);
    if(!await dir.exists()){
      await dir.create();
    }
    return path;
  }


  Future<File> _fileInfo(String id)async{
    final tempDir = await tempDirPath;
    final infoPath = p.join(tempDir, _infoName(id));
    return File(infoPath);
  }
  Future<_CacheInfo?> _getInfo(String id) async{
    final f = await _fileInfo(id);
    final tempDir = f.parent.path;
    if (await f.exists()){
      final info = _CacheInfo.fromJsonData(await f.readAsBytes());
      final expireAt = info.expireAt;
      if(expireAt!= null){
        if(DateTime.now().isAfter(expireAt)){
          return null;
        }
      }

      final cachePath = p.join(tempDir, _cacheName(id, info.ext));
      final fileCache = File(cachePath);
      info.cacheFile = fileCache;
      await f.setLastAccessed(DateTime.now());
      return info;
    }

    return null;
  }
  Future<Cached?> _getCachedFromInfo(_CacheInfo info)async{
    final cacheFile = info.cacheFile;
    if(cacheFile==null){
      return null;
    }

    if(await cacheFile.exists()){
      final data = await cacheFile.readAsBytes();
      if(kDebugMode){
        print("[FLCacheManager] use cache");
      }

      return Cached(data, info.ext, info.meta);
    }

    return null;
  }



  Future<Cached?> _getCache(String id)async{
    final info = await _getInfo(id);
    if(info != null){
      final cache = await _getCachedFromInfo(info);
      if(cache != null){
        return cache;
      }
    }
    return null;
  }
  Future<void> _setCache(String id, String? ext, Uint8List data, DateTime? expireAt, String? etag, Map<String, dynamic> meta)async{
    final fileInfo = await _fileInfo(id);
    final info = _CacheInfo()
      ..expireAt=expireAt;
    if(ext!= null){
      info.ext=ext;
    }
    info.etag = etag;
    info.meta=meta;
    if(await fileInfo.exists()){
      await fileInfo.delete();
    }
    await fileInfo.create();
    await fileInfo.writeAsBytes(info.toJson());

    final cachePath = p.join(fileInfo.parent.path, _cacheName(id, info.ext));
    final fileCache = File(cachePath);


    if(await fileCache.exists()){
      await fileCache.delete();
    }
    await fileCache.create();
    await fileCache.writeAsBytes(data);

  }

  Future<Cached> _getCachedFromReader(Reader src, String? ext, CallbackOnProcess? onProcess)async{
    final len =src.length;
    var readLen = 0;
    final stream = src.data;
    final completer = Completer<Uint8List>();
    final chunks = <List<int>>[];

    await for (final chunk in stream) {
      chunks.add(chunk);
      readLen+= chunk.length;
      if(onProcess!= null){
        onProcess(readLen, len);
      }
    }
    completer.complete(Uint8List.fromList(chunks.expand((x) => x).toList()));
    final data = await completer.future;

    return Cached(data, ext, src.meta);
  }
  /// [getCached] returns data use cache if [id] is same, if no cache, use [readerBuilder] to get new data, data save as file with [ext],
  /// data expire at [expireAt], [onProcess] is 0.0 ~ 1.0
  Future<Cached> getCached({
    required String id,
    required  ReaderBuilder readerBuilder,
    DateTime? expireAt,
    CallbackOnProcess? onProcess,
  })async{
    final cache = await _getCache(id);
    if(cache != null){
      return cache;
    }
    final src = await readerBuilder();
    final cached = await _getCachedFromReader(src, src.ext, onProcess);
    await _setCache(id, src.ext, cached.data, expireAt, null, src.meta);
    return cached;
  }



  /// [getCachedFromUrl] returns data use cache or get from url,
  /// data expire at [expireAt], [onProcess] is 0.0 ~ 1.0
  Future<Cached> getCachedFromUrl({
    required String url,
    DateTime? expireAt,
    CallbackOnProcess? onProcess,
    Map<String, String>? headers,
    Map<String, dynamic>? meta,
  })async{
    var id = md5.convert(utf8.encode(url)).toString();
    id = "url_$id";
    final info = await _getInfo(id);


    var request = http.Request('GET', Uri.parse(url));
    if(headers!=null){
      request.headers.addAll(headers);
    }

    var response = await request.send();
    if (response.statusCode == 200) {
      final etag = response.headers[HttpHeaders.etagHeader];
      if(info != null){
        var useCache = true;


        if(etag != null){
          if(info.etag != etag){
            useCache=false;
          }
        }

        if(useCache){
          final cached =await _getCachedFromInfo(info);
          if(cached!= null){
            return cached;
          }
        }
      }

      final length = response.contentLength??0;

      String? ext;
      final contentType = response.headers[HttpHeaders.contentTypeHeader];
      if(contentType!= null){
        final l = contentType.split("/");
        if(l.length==2){
          ext = l[1];
        }
      }
      final reader = Reader(response.stream, length);
      if(meta!= null){
        reader.meta = meta;
      }

      final cache = await _getCachedFromReader(reader, ext, onProcess);
      await _setCache(id, ext, cache.data, expireAt, etag, cache.meta);
      return cache;

    } else {
      if(info != null){
        final cache = await _getCachedFromInfo(info);
        if(cache != null){
          return cache;
        }
      }

      throw Exception("Http error: ${response.statusCode}");
    }
  }
}
